Panama 项目简介 - 第 2 部分:可变参数函数

简介

TL;DR。本文探讨了使用外部函数和内存 API(Panama 项目)在 Java 中使用本机可变参数函数。

Panma flags
照片由 Luis Gonzalez 提供

的目的是 第 1 部分 是介绍外部函数和内存 API,它展示了如何使用相应的 API 类实现对 C printf 函数的下行调用。

  • SymbolLookup – 查找本机函数内存地址。
  • FunctionDescriptor – 声明一个基于 Java 的函数描述符,其中包含一个返回值和参数布局,这些布局对应于 C 中的原始签名。
  • Linker – 从函数描述符及其本机函数地址创建包装本机函数的方法句柄。
  • MemorySession – 分配和释放本机内存段。

“Hello World” 展示的是对 Java 中 C printf 函数的下行调用的简化实现。为了简单起见,该示例没有实现一个关键功能 – C printf 函数的可变参数。可变参数的缺失使得 C printf 实现看起来更像是 Java 标准库中的 PrintStream::println 方法,而不是 PrintStream::printf 方法。PrintStream::printlnPrintStream::printf 之间的关键区别在于后者支持可变参数。

本文解释了如何使用 Java 19 中作为预览功能引入的 外部函数和内存 API 从 Java 调用本机 C 可变参数函数(下行调用)。


可变参数函数,可变参数

在 C/C++ 中,可变参数函数(例如 printf)是一种函数类型,它接受可变数量的参数。可变参数函数的参数列表应始终以至少一个命名参数开头,并始终以 ... 参数结尾。

int printf(const char * __restrict, ...);

在运行时,函数每次调用最多可以接受 127 个可变参数。

printf("hello world");
printf(" I'm a=%s, I'm b=%s", a, b);

在 C/C++ 中,可变参数在签名中没有任何关联的类型。在 Java 中,情况正好相反 – 可变参数的类型是必需的。

Object someMethod(Object... varargs);

Java 开发人员经常使用使用 java.lang.Object 的广泛可变参数类型定义,因为所有 Java 类型都继承自它。有了上面描述的所有这些事实,我们现在需要了解如何使用外部函数和内存 API 实现 C 可变参数。

命名参数和可变参数的运行时表示

在第 1 部分中,C printf FunctionDescriptor 有以下实现

FunctionDescriptor function = FunctionDescriptor.of(
        JAVA_INT.withBitAlignment(32), ADDRESS.withBitAlignment(64)
        );

C printf 是一个可变参数函数,但在函数描述符中没有可变参数的任何指示。

根据 C printf 定义,它既有命名(位置)参数 (const char * __restricted),也有可变参数 (...),而 C printf 函数的 Java 入口点 (MethodHandle::invoke) 仅接受可变参数参数(命名参数和可变参数之间没有区别)。

public final native @PolymorphicSignature Object invoke(Object... args) throws Throwable;

因此,无论向本机函数传递哪种命名参数和可变参数组合,它们都必须作为 java.lang.Object 数组传递,或者作为可变参数传递。

var allArgs = new Object[] {namedArg1, ..., NamedArgN, varArg1, ..., varArgN};
        methodHandle.asSpreader(Object[].class, allArgs.length).invoke(allArgs);

或者作为可变参数传递,命名参数在前,可变参数在后,以保持顺序并符合 FunctionDescriptor 定义。

methodHandle.invoke(namedArg1, ..., NamedArgN, varArg1, ..., varArgN);

除了 C 方法签名(返回值、命名参数顺序和可变参数)之外,FunctionDescriptor 还声明了该相同本机函数的 Java 方法类型。因此,要执行本机调用,Java 运行时必须知道方法句柄以及如何根据方法类型调用本机代码。

本机函数描述符和方法类型

FunctionDescriptor 是调用本机函数的方法类型验证(安全类型转换)的关键,也是 JVM 在尝试将 MethodHandle::invoke 方法类型与函数描述符方法类型匹配时唯一依赖的实体。

在运行时,调用 MethodHandle::invoke,JVM 会尝试在从函数描述符派生的 MethodType 之间进行安全类型转换。

//                named arg      ret-value
//                <ADDRESS>      <JAVA_INT>
MethodHandle  (  Addressable  )     int

以及从传递给 MethodHandle::invoke 的参数(命名参数和可变参数)创建的方法类型。JVM 会将类型不匹配识别为异常情况。

在 C printf 的情况下,JVM 会检查它是否可以将提交给 MethodHandle::invoke 的命名参数和可变参数组合安全地转换为从 C printf 函数描述符的 Java 实现派生的方法类型。

在第 1 部分中,MethodHandle::invoke 被调用,其中包含一个封装在内存段 (MemorySegment) 中的 java.lang.String 对象。

MemorySegment cString = memorySession.allocateUtf8String(str + "\n");
        int res = (int) printfMethodHandle.invoke(cString);

在调用期间,JVM 将使用参数 (MethodHandle(MemorySegment)int) 创建一个方法类型,然后 JVM 将尝试将其转换为从函数描述符派生的方法类型。

//    <descriptor method type>       <invoke method type>
( MethodHandle(Addressable)int ) MethodHandle(MemorySegment)int

注意:这种类型转换过程将成功,因为 MemorySegment 接口扩展了 Addressable 接口

关键要点是 JVM 利用 FunctionDescriptor 返回值和参数布局来创建方法类型。这意味着 JVM 在调用本机函数期间将强制执行参数类型、顺序和数量以及返回值类型验证。

var emptyDescriptor = FunctionDescriptor.of(JAVA_INT);
System.out.println(Linker.downcallType(emptyDescriptor));

var descriptorWithNamedArg = emptyDescriptor.appendArgumentLayouts(ADDRESS);
System.out.println(Linker.downcallType(descriptorWithNamedArg));
()int
(Addressable)int


使用外部函数和内存 API 实现 C 可变参数函数

新的外部函数和内存 API 提供了一种方法用于 显式可变参数定义

public FunctionDescriptor asVariadic(MemoryLayout... variadicLayouts)

通过 FunctionDescriptor::asVariadic 提供给 FunctionDescriptor 生成器的参数布局将成为遵循命名参数类型的强制性方法类型的一部分。

var descriptorWithNamedAndVariadicArg = descriptorWithNamedArg
        .asVariadic(ADDRESS, JAVA_INT);
System.out.println(Linker.downcallType(descriptorWithNamedAndVariadicArg));
(Addressable,Addressable,int)int

假设我们想要实现以下 C printf 下行调用。

printf("My name is %s, age %d\n",  "Denis", 31)

C printf 将接受 char * pint 作为可变参数,因此函数描述符必须按相应顺序显式声明它们。

FunctionDescriptor descriptorWithNamedAndVariadicArg = FunctionDescriptor.of(
        JAVA_INT.withBitAlignment(32), ADDRESS.withBitAlignment(64)
).asVariadic(ADDRESS.withBitAlignment(64), JAVA_INT.withBitAlignment(32));

与 C printf 定义相比,descriptorWithNamedAndVariadicArg 描述符包含有关可变参数(类型、顺序和数量)的更多详细信息。对由这种描述定义的函数的调用将如下所示。

    var namedArg = memorySession.allocateUtf8String("My name is %s, age %d\n");
    var nameVararg = memorySession.allocateUtf8String("Denis");
    var ageVararg = 31;

    var ret = (int) printfHandle.invoke(namedArg, nameVararg, ageVararg);

JVM 将检查它是否可以将调用方法类型安全地转换为从 function 描述符派生的方法类型。

System.out.println(Linker.downcallType(descriptorWithNamedAndVariadicArg));
(Addressable,Addressable,int)int

可变参数的吸引力在于它们的变长性质。但是,使用它们可能会给 Java 开发人员带来更多工作,例如为每种可能的参数组合创建一个新的描述符和相关的方法句柄。因此,以下代码将失败。

(int) printfHandle.invoke(namedArg, nameVararg); // method type: (MemorySegment,MemorySegment,Void)int
(int) printfHandle.invoke(namedArg); // method type: (MemorySegment,Void,Void)int

对 C printf 的每次调用都将失败,并出现相应的异常。

Exception in thread "main" java.lang.RuntimeException: java.lang.invoke.WrongMethodTypeException: 
    cannot convert MethodHandle(Addressable,Addressable,int)int to (MemorySegment,MemorySegment,Void)int

Exception in thread "main" java.lang.RuntimeException: java.lang.invoke.WrongMethodTypeException: 
    cannot convert MethodHandle(Addressable,Addressable,int)int to (MemorySegment,Void,Void)int

这些调用将失败并引发异常,因为实际方法类型与预期类型不匹配。Java 开发人员面临的主要挑战是保持与函数描述符兼容的可变参数组合。每种新的命名参数和可变参数组合都需要一个新的函数描述符,最终还需要一个新的相关方法句柄。

调用参数、函数描述符和方法句柄之间存在很强的依赖关系。调用必须始终符合函数描述符。如果不符合,将导致异常。


灵活性和性能

基于提前声明可变参数的解决方案在许多情况下都有效,因为并非所有可变参数函数都像 C printf 函数一样。同时,像 C printf 这样的本机函数可以处理各种可变参数。因此,目标是在调用像 C printf 这样的函数时保持相同级别的灵活性。

不幸的是,本文中描述的解决方案缺乏可变参数提供的灵活性。提前声明可变参数会将它们变成方法类型不可分割的一部分,这意味着这些参数在调用时将变得强制性。

另一个问题是性能影响,因为有太多特定于调用的组件。例如,运行时必须为每种可变参数组合创建一个新的方法句柄实例。

Linker 视为编译器。它必须在调用之前知道需要创建哪种方法句柄。越早发生,理想情况下在 JVM 启动时发生,对应用程序运行时的影响就越小。因此,出于性能原因,每种可变参数组合的方法句柄应存储在静态 final 字段中,然后从那里使用。

class PrintfImpls {
    static final FunctionDescriptor PRINTF_BASE_TYPE = FunctionDescriptor.of(JAVA_INT, ADDRESS);
    static final Linker LINKER = Linker.nativeLinker();
    static final Addressable PRINTF_ADDR = LINKER.defaultLookup().lookup("printf").orElseThrow();

    static MethodHandle specializedPrintf(MemoryLayout... varargLayouts) {
        FunctionDescriptor specialized = PRINTF_BASE_TYPE.asVariadic(varargLayouts);
        return LINKER.downcallHandle(PRINTF_ADDR, specialized);
    }

    public static final MethodHandle WithInt = specializedPrintf(JAVA_INT);
    public static final MethodHandle WithString = specializedPrintf(ADDRESS);
    public static final MethodHandle WithIntAndString = specializedPrintf(JAVA_INT, ADDRESS);
}

注意:性能问题在于 JIT 编译器 (C2) 将尝试检查并拆分方法句柄,以编译通过方法句柄的调用,就像调用任何正常的 Java 方法一样。但是,只有当方法句柄是常量(定义为静态 final 字段)时,它才能做到这一点。


结论

原生函数的声明方式与其调用方式之间存在着强烈的关联,因此函数必须按照声明的方式进行调用,即没有灵活性。不幸的是,这会影响可变参数函数的实现,因为在调用之前需要定义一个包含可变参数布局的函数描述符。这种方法与可变参数的本质相矛盾,因为可变参数的数量、类型和顺序通常是不可预测的。

因此,Java 开发人员必须处理这种缺乏灵活性,并通过让运行时负责调用来保持所需的性能水平,但不能在调用原生函数之前构建下行调用方法句柄。

尽管语气悲观,但有一种方法可以解决可变参数原生函数带来的挑战,即委托 jextract 来提供原生函数的基础设施代码。 jextract 是由 Panama 项目开发的工具,其目标是从 C 头文件和相关的共享库生成 Java 源代码类,将在后续文章中详细介绍。


来源

本文的源代码可以在 这里 找到。